iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 23

Day 23:實作 RDB 保存與啟動時的 AOF/RDB 自動加載資料恢復

  • 分享至 

  • xImage
  •  

昨天先把 RDB 的資料模型定下來,今天就直接在 code/db/rdb.goSaveRDBLoadRDB,順便把 Server 啟動時的資料恢復流程接起來。


RDB 保存與加載的實作

1. 寫入快照:SaveRDB

保存時先用讀鎖(RLock)拷貝目前 DB map 的快照。為了避免寫到一半當機導致舊檔也壞掉,我採用原子更名策略:先寫到臨時檔 dump.rdb.tmp,完成後再用 os.Rename 替換舊檔。

這邊我第一版直接存原檔,結果寫一半當機檔案直接壞掉,後來才加上臨時檔跟 os.Rename 原子替換,這真的是血淚教訓。

func (db *DB) SaveRDB(filename string) error {
	db.mu.RLock()
	snapshot := make(map[string]rdbEntry, len(db.data))
	for k, v := range db.data {
		if v.isExpired() { continue }
		
		// 將複雜資料結構扁平化為 Gob 可編碼型態
		var serializableVal interface{}
		switch v.dataType {
		case TypeString:
			serializableVal = v.val.([]byte)
		case TypeList:
			l := v.val.(*list.List)
			// ... 收集為 [][]byte ...
		}
		snapshot[k] = rdbEntry{
			DataType: v.dataType,
			Val:      serializableVal,
			ExpireAt: v.expireAt,
		}
	}
	db.mu.RUnlock()

	// 原子覆蓋寫入
	tempFilename := filename + ".tmp"
	file, _ := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	defer file.Close()

	encoder := gob.NewEncoder(file)
	_ = encoder.Encode(snapshot)
	
	_ = file.Sync()
	_ = file.Close()
	return os.Rename(tempFilename, filename)
}

2. 加載快照:LoadRDB

載入時,把 Gob 編碼的檔案讀回來,再還原成 Go 裡真正要用的雙向鏈結串列或跳躍表結構:

func (db *DB) LoadRDB(filename string) error {
	file, err := os.Open(filename)
	if err != nil { return err }
	defer file.Close()

	var snapshot map[string]rdbEntry
	decoder := gob.NewDecoder(file)
	_ = decoder.Decode(&snapshot)

	db.mu.Lock()
	defer db.mu.Unlock()

	db.data = make(map[string]*entry)
	for k, v := range snapshot {
		if !v.ExpireAt.IsZero() && time.Now().After(v.ExpireAt) { continue } // 排除已過期

		var val interface{}
		switch v.DataType {
		case TypeList:
			l := list.New()
			// 還原為雙向鏈結
			// ...
			val = l
		}
		db.data[k] = &entry{
			dataType: v.DataType,
			val:      val,
			expireAt: v.ExpireAt,
		}
	}
	return nil
}

啟動時的資料自動恢復機制

Redis Clone 啟動時,要先決定該讀哪一份持久化資料。這裡我照 Redis 的方向處理:

  1. 優先讀取 AOF 檔案:因為 AOF 記錄了每次寫操作,資料最為完整。
  2. 次要讀取 RDB 檔案:若 AOF 未啟用或不存在,則嘗試載入 RDB 檔案恢復資料。
  3. 若兩者皆無:以空資料庫狀態啟動。

我們在 code/server/server.goStart() 階段實作了此邏輯:

func (s *Server) Start() error {
	// 1. 優先嘗試加載 AOF
	aofLogger, err := db.NewAofLogger("appendonly.aof", s.dbEngine)
	if err == nil {
		s.aofLogger = aofLogger
		dummyClient := command.NewClient(nil, false)
		
		// 載入與重播
		_ = s.aofLogger.LoadAndReplay(func(val resp.Value) {
			s.dispatcher.Dispatch(dummyClient, val)
		})
	} else {
		// 2. 若無 AOF,嘗試加載 RDB 快照
		err := s.dbEngine.LoadRDB("dump.rdb")
		if err == nil {
			log.Println("成功從 RDB 快照載入恢復資料")
		}
	}
	// 3. 開始 TCP 監聽
	// ...
}

這個順序的理由很簡單:AOF 通常記得比較細,所以有 AOF 就先 replay;沒有 AOF 時,再退回讀 RDB 快照。


跑起來看看

來驗證一下我們辛苦寫的持久化恢復功能有沒有作用。先寫點資料然後重啟 server:

# 終端機 1: 寫入資料
$ redis-cli
> SET user "sky"
# 預期回覆:OK
> SHUTDOWN

接著把 server 重新跑起來,觀察 log:

# 終端機 2: Server 啟動畫面
$ go run main.go
2026/08/13 22:45:10 [INFO] 找到 appendonly.aof,開始載入...
2026/08/13 22:45:10 [INFO] 成功從 AOF 重播 3 條命令
2026/08/13 22:45:10 [INFO] Server started on :6379

最後回到 cli 檢查剛才的 user 還在不在:

$ redis-cli
> GET user
# 預期回覆:"sky"

這就代表資料平安活過重啟了!


總結

今天把 RDB 的保存、載入,以及啟動時 AOF/RDB 的判斷順序都串起來了。

明天換個方向,來弄記憶體淘汰機制 LRU。記憶體爆掉可不是開玩笑的。


上一篇
Day 22:RDB (Redis Database) 快照持久化原理與binary serialization設計
下一篇
Day 24:記憶體淘汰機制(Eviction Policy)LRU 演算法原理解析與實作
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言